import sys
import re
import argparse
import os
from   collections import Counter, defaultdict

_splsummarize = ("weakaux", "passive", "info")
_splfull = ("abbrv", "math", "ofofof", "punct", "repeats", "spell", "words")
_splstd = _splsummarize + _splfull

### command line interface ############################################

def get_argument_parser():
    desc = "StylPyl is an advisor for scientific writing.\n"\
        "(c) Sven Rahmann.  License: MIT"
    p = argparse.ArgumentParser(prog="StylPyl",
        description=desc, epilog="In development.")
    p.add_argument("--file", nargs="+", default=[],
        help = "file(s) to be analyzed; omit to read from stdin")
    p.add_argument("--terse", "-t", action="store_true",
        help = "do not echo lines of the tex(t) file")
    p.add_argument("--ungrouped", "-u", action="store_true",
        help = "sort, but do not group suggestions by line number range")
    p.add_argument("--american", "-a", dest="language", action="store_const",
        const = "AE", default="AE",
        help = "expect American English, complain on British English (default)")
    p.add_argument("--british", "-b",  dest="language", action="store_const",
        const = "BE", default="AE",
        help = "expect British English, complain on American English")
    p.add_argument("--halt", action="store_true",
        help="halt after writing converted text to stdout")
    p.add_argument("--include","-i", nargs="+", default = [],
        help = "custom .spl files to include")
    p.add_argument("--summarize","-s", nargs="+", default = [],
        help = ".spl files to include, but report only summaries for")
    p.add_argument("--exclude","-x", nargs="+",
        choices = _splstd, default = [],
        help = "standard .spl files to exclude")
    p.add_argument("--convert", "-c",
        choices=frozenset(("detex","none","default")), default = "default",
        help = "file conversion method before pattern search (default: detex for .tex)")
    p.add_argument("--debug", action="store_true",
        help = "print debug information, e.g., matched pattern")
    return p


### process .spl files #################################################

class SPLError(RuntimeError):
    pass

def find_spl_file(fname, debug=False):
    """search several directories for an spl file with module name 'fname';
    return the full path where it is found. Search path is:
    (1) path of StylPyl executable; (2) $HOME/.stylpyl; (3) current dir."""
    try:
        modulepath = os.path.dirname(__file__)
    except NameError:
        modulepath = "."
    dirs = (modulepath, ".",)  # TODO: $HOME/.stylpyl
    found = None
    for d in dirs:
        attempt = os.path.join(d,fname) + ".spl"
        if debug: print("#(stylpyl)# attempting to read "+attempt, file=sys.stderr)
        if os.path.exists(attempt):
            found = attempt
            break
    if found is None:
        raise SPLError(".spl file {} not found.".format(fname))
    return os.path.realpath(found)

def _patternhint_update(pattern, hint, dcp, dhint, fileloc):
    # special-case patterns that start with '(?-i)', which is a PERL extension
    # for "do not ignore case", overriding the usual re.IGNORECASE option.
    # Python does not understand this extension, so we'll proceed as follows;
    # this is a cheap hack: If the pattern starts like this, we will remove
    # this prefix from the pattern and note this in the flags.
    flags = re.IGNORECASE
    if pattern.startswith('(?-i)'):
        pattern = pattern[5:]
        flags -= re.IGNORECASE
    # try to compile pattern to check whether it is valid
    try:
        re.compile(pattern, flags=flags)
    except re.error:
        raise SPLError("Error in pattern '{}' at {}".format(pattern,fileloc))
    # modify pattern by replacing each group of spaces by a whitespace-regex
    # !! re.sub interprets backslashes (\) in the replacement string,
    # !! so desired literal backslashes need to be doubled (\\)!
    pat = re.sub(r'\s+', r'[\\s,\\.!;]+', pattern)  # note \\ instead of \
    # compile and check again
    try:
        cp = re.compile(pat, flags=flags)
    except re.error:
        raise SPLError("Error in modified pattern '{}'; original '{}' at {}".format(pat,pattern,fileloc))
    # modify hint by expanding shortcuts
    hint = hint.replace('$Cr ', "Consider removing ")
    hint = hint.replace('$Cc ', "Consider changing ")
    hint = hint.replace('$Ao ', "Avoid overusing ")
    hint = hint.replace('$Cus ', "Consider using simply ")
    # check if pattern already exists with a different hint    
    if (pattern in dhint) and (dhint[pattern]!=hint):
        raise SPLFormatError("Pattern above "+fileloc+" existed with different hint")
    dcp[pattern] = cp
    dhint[pattern] = hint

    
def load_spl_file(fname, debug=False):
    """load the spl file with the given name into a dict;
    return the dict d such that d[pattern]=hint."""
    dcp = dict();  dhint = dict()
    with open(fname) as splfile:
        state = 0
        for i,line in enumerate(splfile):
            if line.startswith("#"): continue
            line = line.rstrip("\n")
            if len(line) == 0: continue
            fileloc = "{}:{}".format(fname,i)
            if state == 0:  # pattern
                pattern = line
                state = 1
            elif state == 1:  # hint
                hint = line
                state = 0
                _patternhint_update(pattern, hint, dcp, dhint, fileloc)
            else:
                raise SPLError("Undefined state at "+fileloc)
    if state != 0: raise SPLError("Pattern without hint at EOF: "+fileloc)
    return dcp, dhint


def load_spls(args):
    """load all .spl files and return three dicts:
    cpattern[pattern] = compiled_pattern;
    hint[pattern] = hintstring;
    level[pattern] = verbosity"""
    # decide on a language-specific spl file
    langspl = set(("ae","be"))
    if  args.language == "AE": 
        langspl -= set(("ae",))
    else:
        langspl -= set(("be",))
    # build spl file lists
    full = set(_splfull) | langspl
    summ = set(_splsummarize)
    for x in args.include:
        full.add(x)
        summ.discard(x)
    for x in args.summarize:
        summ.add(x)
        full.discard(x)
    for x in args.exclude:
        full.discard(x)
        summ.discard(x)
    # build level dictionary
    filelevel = dict()
    for x in full: filelevel[x] = 1
    for x in summ: filelevel[x] = 0
    # find and load each spl file into two dicts: hint, level
    cpatterns = dict();  hints = dict();  levels = dict()
    for x in full | summ:
        fname = find_spl_file(x, args.debug)
        dcp, dhint = load_spl_file(fname, args.debug)
        lvl = filelevel[x]
        dlevel = {k: lvl for k in dhint.keys()}
        cpatterns.update(dcp);  hints.update(dhint);  levels.update(dlevel)
    return cpatterns, hints, levels

## conversion ###################################################

def get_text(fname, convert="default"):
    """load text or tex file named 'fname' into a string
    and convert it according to the arguments.
    Return (converted text string, list of original text lines)"""
    textlines = None
    if fname == "-":
        textlines = sys.stdin.readlines()
    else:
        with open(fname) as f:
            textlines = f.readlines()
    text = "".join(textlines)
    removenewline = lambda x: x.rstrip("\n")
    textlines = list(map(removenewline, textlines))
    #print("{} has {} lines!".format(fname, len(textlines)))
    conv = _convert[convert]
    convtext = conv(text, fname)
    return convtext, textlines

def convert_default(text, fname):
    # default behavior: run convert_detex for .tex files, else convert_none.
    _, ext = os.path.splitext(fname)
    if ext == '.tex':
        return convert_detex(text, fname)
    return convert_none(text, fname)
    
def convert_detex(text, fname):
    # remove line breaks, such as "\\[2ex]"
    text = re.sub(r'\\\\(\[[^\]]*\])?', "", text)
    # replace \begin{equation}, \begin{align} by $
    text = re.sub(r'\\begin\{(equation|align)\*?\}', " $", text)
    # remove all other \begin{}s
    text = re.sub(r'\\begin\{[^\}]*\}', "", text)
    # remove all \end{}s
    text = re.sub(r'\\end\{[^\}]*\}', "", text)
    # replace \[ --> $
    text = re.sub(r'\\\[', " $", text)
    # remove \]
    text = re.sub(r'\\\]', "", text)
    # replace ~ by spaces
    text = re.sub(r'~+', " ", text)
    # put a full stop at beginning of text
    text = ". " + text
    return text

def convert_none(text, fname):
    # do not convert; only prepend a full stop
    # in order to recognize first character as beginning of sentence.
    return ". " + text

_convert = {'default': convert_default, 'detex': convert_detex, 'none': convert_none}


## reporting ###################################################

def report(fname, text, textlines, cpattern, hint, level, args):
    """report on a given text (assumed to be converted)"""
    grouped = not args.ungrouped;  terse = args.terse
    if terse: grouped = False  # ignore --grouped if --terse.
    # output current filename
    print("< {}:".format(fname))
    if grouped: print()
    # match each pattern against the text, count pattern usage
    pcount = Counter()
    suggestions = defaultdict(list)
    for pat, cp in cpattern.items():
        myhint = hint[pat]
        mylevel = level[pat]
        for match in cp.finditer(text):
            pcount[pat] += 1
            if mylevel < 1: continue
            _update_suggestions(suggestions, fname, match, myhint, textlines, terse, grouped)
    # output detailed suggestions per line range        
    for linerange in sorted(suggestions.keys()):
        print("\n".join(suggestions[linerange]))
        if grouped: print()
    # output summary statistics
    fnamedisplay = fname if not args.terse else ""
    summarypatterns = [(pcount[pat],pat) for pat in level.keys()
                       if level[pat]==0 and pcount[pat]>0]
    if len(summarypatterns) > 0:
        summaryheader = "|global|" if not terse else ""
        if not terse: print(summaryheader)
        for (count, pat) in sorted(summarypatterns, reverse=True):
            if terse:
                print(">|global| {}x {}".format(count, hint[pat]))
            else:
                print("> {}x {}".format(count, hint[pat]))
    print()
            

_ENDOFPREVIOUSSENTENCE = re.compile(r'^\S{0,3}[ \t\r\f]*(\s+)')

def _update_suggestions(suggestions, fname, match, hint, textlines, terse=False, grouped=True):
    text = match.string
    this = match.group()
    startline = 1 + text.count("\n", 0, match.start())
    endline = 1 + text.count("\n", 0, match.end())
    # beautify 'this'; adjust starline and endline accordingly
    previous = _ENDOFPREVIOUSSENTENCE.search(this)
    if previous:
        whitespace = previous.group(1)
        newlines = whitespace.count("\n")
        if newlines > 0:
            startline += newlines
            this = _ENDOFPREVIOUSSENTENCE.sub("", this)
    this = this.strip()
    this = re.sub(r'\s+', " ", this)
    # build line range string
    linestr = str(startline) if startline == endline else "{}-{}".format(startline,endline)
    linerange = (startline, endline)
    # beautify hint by replacing '$this' with the contents of 'this'.
    # we use str.replace instead of re.sub here because
    #   (a) str.replace is simpler and we do not match a regex here,
    #   (b) re.sub would interpret backslashes in the replacement string.
    hint = hint.replace('$this', this)
    # generate output as a list of strings ('display')
    display = []
    if terse:  # --grouped does not matter then
        display.append(">|{}| {} <<{}>>".format(linestr, hint, this))
    else:  # not terse, but maybe grouped
        if (not grouped) or (linerange not in suggestions):
            for i in range(startline-1, endline):
                display.append("|{}| {} ".format(i+1,textlines[i]))
        display.append("> {} <<{}>>".format(hint,this))
    suggestions[linerange].extend(display)


################################################################

def main(args=None):
    """main function"""
    p = get_argument_parser()
    args = p.parse_args() if args is None else p.parse_args(args)
    # read spl libraries
    cpattern, hint, level = load_spls(args)
    # iterate through arguments, reporting on each text
    files = args.file
    if len(files) == 0:  files = ["-"]  # no --file: read from stdin
    for fname in files:
        text, textlines = get_text(fname, args.convert)
        if args.halt:
            print("# {}:".format(fname))
            print(text)
            print()
            continue
        report(fname, text, textlines, cpattern, hint, level, args)

def test():
    path = os.path.expanduser("~/projects/hypernetworks/IntegrativeBiologyPaper")
    file = os.path.join(path,"paper.tex")
    file = os.path.relpath(file)
    main(args=["--file", file])

if __name__ == "__main__":
    #test()
    main()
    
################################################################

